昨天實作的內容是登出流程,這個指的是使用者「主動」按下登出的過程。因為登入應用程式時,是透過 Session ID 來標識相同裝置的,因此即便使用者登入很多不同的應用程式,Hydra 還是能透過 Session ID 查到這些資訊。
但,不同裝置就無法透過這個方法來實現了。比方說使用者有 Mac 與 Windows 兩台電腦,則兩台電腦都完成登入後,Mac 登出是不會影響 Windows 的,因為這兩台裝置所記錄的 Session ID 是不同的。
假想下面幾個情境
兩個問題很相似,但有點不同,一個是針對特定裝置的登入狀態移除,另一個是移除所有登入狀態。這次 30 天鐵人賽只說明後者,把所有登入狀態清除。而移除特定裝置的登入狀態,就看還有沒有力氣寫第 31 天了。
昨天的 Backchannel 其實就是清除應用程式端的登入狀態的方法了,只是昨天的情境是 Hydra 主動發出,實際上 Back-Channel Logout 有定義另一個欄位是 sub。
這個欄位跟 sid 是互有關係的,下表是這兩個欄位相互組合的意義:
| sub | sid | 說明 | 
|---|---|---|
| 沒有 | 沒有 | 不合法的 Token | 
| 沒有 | 有 | 清除指定 Session 的狀態 | 
| 有 | 沒有 | 清除指定使用者的狀態 | 
| 有 | 有 | 清除指定使用者,且指定 Session 的狀態。 | 
從這個表可以了解,其實如果要清除應用程式端的狀態,只要丟一個帶有 sub 欄位的 Logout Token 就行了。但因為 Hydra 沒實作這件事,所以只能自己來產 JWT,還好 Hydra 有提供取 JWK 的 API:PUT /keys/{set}/{kid},從 DB table hydra_jwk 可以查得到,set 是指 hydra.openid.id-token,而 kid 就是指 private:hydra.openid.id-token 了。會這麼選擇的原因是,Back-Channel Logout 協定裡有提到驗證簽章的方法跟 ID Token 一樣;另外就是,等下是要實作簽章而不是驗證,因此需要私鑰。
$response = $admin->getJsonWebKey('private:hydra.openid.id-token', 'hydra.openid.id-token');
$privateKey = $response->getKeys()[0]->jsonSerialize();
再來是實作簽章的程式,之前驗證的套件有大概看過,這次要用 JWSBuilder。整體寫起來如下:
private function buildLogoutToken(array $privateKey, string $client, string $sub): string
{
    $jwsBuilder = new JWSBuilder(new AlgorithmManager([
        new RS256(),
    ]));
    $jws = $jwsBuilder
        ->withPayload(json_encode([
            'aud' => [
                $client,
            ],
            'events' => [
                'http://schemas.openid.net/event/backchannel-logout' => new stdClass(),
            ],
            'iat' => time(),
            'iss' => 'http://127.0.0.1:4444/',
            'jti' => Str::uuid()->toString(),
            'sub' => $sub,
        ]))
        ->addSignature(new JWK($privateKey), ['alg' => 'RS256',])
        ->build();
    $serializer = new CompactSerializer();
    return $serializer->serialize($jws);
}
JWSBuilder 要小心一個問題是,它是有狀態的物件,因此如果要產生新的 JWS 時,需要再使用 create() 方法清除狀態。上面因為 function 結束後,它的生命週期就結束了,所以沒有這個問題。
接著要取得所有應用程式,這個 Hydra 也有提供 API 了:
$clients = $admin->listOAuth2Clients()
最後就是每個應用程式都送出清除使用者狀態的請求就行了。
collect($clients)
    ->filter(fn(OAuth2Client $client) => !empty($client->getBackchannelLogoutUri()))
    ->each(function (OAuth2Client $client) use ($sub, $privateKey) {
        $logoutToken = $this->buildLogoutToken($privateKey, $client->getClientId(), $sub);
        $uri = $client->getBackchannelLogoutUri();
        Http::post($uri, [
            'logout_token' => $logoutToken,
        ]);
    });
授權伺服器端的實作不難,困難的地方在應用程式端的實作。目前覺得最適合的作法如下:
sid 對應用程式 Session 的 mapping(以 Redis 來說,使用 Key Value 即可)sub 對 sid 的 mapping(以 Redis 來說,可以使用 SET)當清除 sid 的時候,除了第一個記錄要清外,第二個記錄也要清。
當清除 sub 的時候,使用第二個記錄把相關的 sid 找出來,就能把第一個記錄清掉。
當兩個資訊都有的時候,先從 sub 找 sid,有找到的話就照清除 sid 的步驟處理。
應用程式清除 Session 程式碼就先偷懶不實作了。
即便把應用程式的狀態清除,因為 Hydra 的登入狀態還在(remember 功能),因此在按下應用程式登入的時候,依然會被 skip 而跳過身分驗證流程,因此需要再呼叫 Hydra 的一個 API 來清除登入狀態:
$admin->revokeAuthenticationSession($sub)
程式碼可以參考 GitHub Commit。